fix(storage): tie-break GSI pagination on the full base primary key - #239
Open
LeeroyHannigan wants to merge 1 commit into
Open
fix(storage): tie-break GSI pagination on the full base primary key#239LeeroyHannigan wants to merge 1 commit into
LeeroyHannigan wants to merge 1 commit into
Conversation
Closes #238. A Query on a GSI whose index sort keys are duplicated returned page one plus a LastEvaluatedKey, and resuming from that key returned zero items with no error, so a paginating client read page one and concluded it had everything. Both backends built the resume predicate as idx_sk > :sk OR (idx_sk = :sk AND base_sk > :base_sk) treating the base table's sort key as the uniqueness tie-breaker. It is not one. A GSI entry is unique on (index SK, base PK, base SK): many base partitions can project the same index SK, and those rows can also share a base SK. When they did, both disjuncts were false and page two came back empty. The predicate is now idx_sk > :sk OR (idx_sk = :sk AND (base_pk > :base_pk OR (base_pk = :base_pk AND base_sk > :base_sk))) and the ORDER BY gained base_pk so the ordering and the predicate agree. They have to: ordering by base SK alone leaves rows that share an index SK and a base SK in an arbitrary order, which no ExclusiveStartKey can resume from deterministically. The index scan path already ordered by the full base key, so this also removes a divergence between the query and scan paths. This is why a hash-only base table was unaffected, and why TestGSIOnHashOnlyBaseTable passed throughout: there the base partition key is the whole base primary key, so the existing tie-breaker was already complete. LSI behaviour is deliberately unchanged. An LSI query always constrains the partition key, so every row shares it and the base sort key alone does identify a row; it is also a user-visible sort dimension, so it continues to follow ScanIndexForward. Only the GSI path changed. The postgres PaginationBinds variants are re-documented accordingly, since BaseSkOnly is now LSI-only and its old comment described the broken case as correct. Verified on both backends with a negative control. Against unmodified main the three new tests give 2 failed, 1 passed on postgres and the same on sqlite; with the fix, 3 passed on both. The test that passes in both directions is the one where the base sort keys differ, which is the case the old tie-breaker could handle: it is included so a fix that only works when the base sort key breaks the tie cannot pass. Reverse pagination is covered too, because the base key tie-breaker stays ascending while the index sort key reverses, so the predicate and the ORDER BY must agree on that asymmetry. Full tests/test_query_scan.py on postgres with the fix: 81 passed, 2 failed. Both failures are pre-existing on main, unrelated to this change, and pass in CI on the same commit; they are reported separately rather than bundled here.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn and
yesyayen
as code owners
August 6, 2026 10:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Fixes the tie-breaker used to resume a paginated Query on a global secondary index. Both backends built the
ExclusiveStartKeypredicate astreating the base table's sort key as the uniqueness tie-breaker. It is not one. A GSI entry is unique on
(index SK, base PK, base SK): many base partitions can project the same index sort key, and those rows can also share a base sort key. When they did, both disjuncts were false and page two came back empty. The predicate is nowand the
ORDER BYgainedbase_pkso the ordering and the predicate agree. They have to agree: ordering by base sort key alone leaves rows that share an index sort key and a base sort key in an arbitrary order, which noExclusiveStartKeycan resume from deterministically.Changed in
crates/storage-sqlite/src/data/query_scan.rsandcrates/storage-postgres/src/data/query_scan.rs. The postgresPaginationBindsdoc comments indata/query.rsare corrected, becauseBaseSkOnlyis now LSI-only and its previous comment described the broken case as the intended one.LSI behaviour is deliberately unchanged. An LSI query always constrains the partition key, so every row shares it and the base sort key alone does identify a row. It is also a user-visible sort dimension there, so it continues to follow
ScanIndexForward. Only the GSI path changed.This also removes a divergence inside the codebase: the index scan path already ordered by the full base key (
ORDER BY pk, idx_sk, base_pk, base_sk), while the query path did not.Why
A Query on a GSI with duplicated index sort keys returned page one plus a
LastEvaluatedKey, and resuming from that key returned zero items with no error. Nothing failed loudly, so a paginating client read page one and concluded it hadeverything. Silent partial results are worse than an error, because the caller has no signal to retry on.
This is why a hash-only base table was unaffected and why
TestGSIOnHashOnlyBaseTablepassed throughout: there the base partition key is the whole base primary key, so the existing tie-breaker was already complete. Adding a range key to the base table is what exposed it.Closes #238
Testing done
Three new tests in
TestGSIOnCompositeBaseTable(tests/test_query_scan.py), against a live server on both backends, with a negative control in each case.mainThe test that passes in both directions is
test_paginate_duplicate_gsi_sort_keys_distinct_base_sort_keys, the case the old tie-breaker could already handle. It is included so a fix that only works when the base sort key happens to break the tie cannot pass. Reverse pagination is covered too, because the base key tie-breaker stays ascending whilethe index sort key reverses, so the predicate and the
ORDER BYhave to agree on that asymmetry.Regression coverage:
cargo test --workspace: 668 passed, 0 failed, 0 filtered out.cargo fmt --all -- --check: clean, zero diffs.cargo clippy --all-targets -- -D warnings: clean.cargo clippy --workspace --all-targets -- -W clippy::pedantic: 421 warnings,identical to the count on
main, so this change adds none. It does grow onepre-existing
too many linesviolation in the postgres query builder from208/100 to 228/100; that function was already twice over the limit and
splitting it is out of scope for a bug fix.
tests/test_query_scan.pyon postgres with the change: 83 passed,2 failed. Both failures are
TestBaseKeySchemaFlow::test_index_pagination_uses_base_key_schema_for_tiebreakerand
::test_index_scan_pagination_uses_base_key_schema. They fail identicallyon unmodified
mainin my environment and pass in CI on the same commit(
d6afa1e), so they are unrelated to this change. They are not the GSIpropagation race either: they still fail with
gsi_propagation_delay_msset to0, and unlike their siblings they do not poll with
wait_for_gsi_items. I havenot root-caused that divergence and am reporting it separately rather than
bundling it here.
Checklist
cargo test --workspace) - 668 passed, 0 filtered outcargo fmt --check)cargo clippy -- -W clippy::pedantic) - no new warningsversus
main(421 both sides); see the note above on the pre-existingfunction-length violation
PaginationBindsdoc comments, which previously described the broken tie-breaker as correct
Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: n/a. No trait, wire, on-disk or CLI surface changes; this corrects
SQL generation inside two backends to match documented DynamoDB pagination
behaviour.
Breaking changes
None.
LastEvaluatedKeykeeps the same shape and contents, so keys issued by anolder build remain usable.
One behaviour change worth stating explicitly, though it is not breaking: the
order in which rows with tied index sort keys are returned was previously
arbitrary and is now deterministic, ordered by the base primary key. Callers
could not have depended on the old order, since it was the absence of a total
order that made pagination lose rows.
By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.